Datasource: Add file_metadata() to DataSink for per-file write metadata - #23656
Datasource: Add file_metadata() to DataSink for per-file write metadata#23656qzyu999 wants to merge 3 commits into
Conversation
ed75a23 to
a4d415e
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #23656 +/- ##
==========================================
+ Coverage 80.66% 80.86% +0.19%
==========================================
Files 1086 1098 +12
Lines 366694 374644 +7950
Branches 366694 374644 +7950
==========================================
+ Hits 295806 302967 +7161
- Misses 53265 53632 +367
- Partials 17623 18045 +422 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Add FileWriteMetadata struct to datafusion-datasource along with a new default method on the DataSink trait: - file_metadata(): Post-hoc accessor returning per-file path, row count, byte size, and optional format-specific metadata bytes after write_all completes. The default implementation returns an empty Vec, so existing DataSink implementations are unaffected. ParquetSink overrides file_metadata() to expose path, row_count, and byte_size for each written file using the existing self.written HashMap that already collects ParquetMetaData during writes. The typed ParquetMetaData remains accessible via ParquetSink::written() for Rust consumers who need column-level statistics directly. DataSinkExec gains a file_metadata() convenience accessor that delegates to the underlying sink. This enables external table formats (Iceberg, Delta Lake, Hudi) to obtain per-file metadata after a DataFusion write without re-reading file footers or bypassing the write pipeline entirely. Closes apache#23472
a4d415e to
f9c560a
Compare
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Adds a standardized way for DataSink implementations to expose per-file write metadata (e.g., file path, row count, size, and optional format-specific bytes) so external consumers (Iceberg/Delta/Hudi) can commit written files without re-reading footers or downcasting to ParquetSink.
Changes:
- Introduces
FileWriteMetadataand adds a defaultDataSink::file_metadata()accessor (returns empty by default). - Implements
file_metadata()forParquetSinkusing already-collectedwrittenmetadata (no extra I/O). - Adds a
DataSinkExec::file_metadata()convenience method and new unit tests in both crates.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| datafusion/datasource/src/sink.rs | Adds FileWriteMetadata, extends DataSink/DataSinkExec API, and adds trait-level tests. |
| datafusion/datasource-parquet/src/sink.rs | Implements ParquetSink::file_metadata() and adds parquet-specific tests validating behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| fn build_test_ctx(store_url: &ObjectStoreUrl) -> Arc<TaskContext> { | ||
| let tmp_dir = tempfile::TempDir::new().unwrap(); | ||
| let local = Arc::new( | ||
| LocalFileSystem::new_with_prefix(&tmp_dir) | ||
| .expect("should create object store"), | ||
| ); | ||
|
|
||
| let session = SessionConfig::default(); | ||
| let runtime = RuntimeEnv::default(); | ||
| runtime | ||
| .object_store_registry | ||
| .register_store(store_url.as_ref(), local); | ||
|
|
||
| Arc::new( | ||
| TaskContext::default() | ||
| .with_session_config(session) | ||
| .with_runtime(Arc::new(runtime)), | ||
| ) | ||
| } |
There was a problem hiding this comment.
Introduced a TestFixture struct that owns the TempDir alongside the TaskContext and ParquetSink. The _tmp_dir field keeps the directory alive for the entire test lifetime:
struct TestFixture {
_tmp_dir: TempDir,
ctx: Arc<TaskContext>,
sink: Arc<ParquetSink>,
schema: SchemaRef,
}| let row_count = parquet_meta.file_metadata().num_rows() as u64; | ||
| let byte_size: u64 = parquet_meta | ||
| .row_groups() | ||
| .iter() | ||
| .map(|rg| rg.compressed_size() as u64) | ||
| .sum(); |
There was a problem hiding this comment.
Replaced both casts with try_into().unwrap_or(0):
let row_count: u64 = parquet_meta
.file_metadata()
.num_rows()
.try_into()
.unwrap_or(0);
let byte_size: u64 = parquet_meta
.row_groups()
.iter()
.map(|rg| u64::try_from(rg.compressed_size()).unwrap_or(0))
.sum();Also updated the file_metadata_consistent_with_written test assertions to use the same safe conversion pattern.
| /// Sum of compressed row group sizes in bytes. | ||
| /// | ||
| /// Note: this may differ slightly from the actual on-disk file size as it | ||
| /// excludes the Parquet footer, page indexes, and other metadata overhead. | ||
| pub byte_size: u64, |
There was a problem hiding this comment.
Rewrote the doc to be format-neutral:
/// Best-effort total encoded data size in bytes.
///
/// How this value is computed depends on the format. For Parquet it is the
/// sum of compressed row-group sizes (which excludes the footer and page
/// indexes); for other formats it may be the full file size or unavailable
/// (zero). Consumers should treat this as an approximation.
pub byte_size: u64,
timsaucer
left a comment
There was a problem hiding this comment.
This looks like a good addition. I have a couple of suggestions and a question. Also I think the copilot suggestions look like they should be addressed.
| fn make_test_batch(schema: &SchemaRef) -> RecordBatch { | ||
| let col_a: ArrayRef = Arc::new(StringArray::from(vec!["foo", "bar", "baz"])); | ||
| let col_b: ArrayRef = Arc::new(StringArray::from(vec!["one", "two", "three"])); | ||
| RecordBatch::try_new(Arc::clone(schema), vec![col_a, col_b]).unwrap() |
There was a problem hiding this comment.
There is a record_batch! macro that makes this a little more pleasant.
There was a problem hiding this comment.
Replaced manual StringArray + RecordBatch::try_new with the macro:
fn make_test_batch(_schema: &SchemaRef) -> RecordBatch {
record_batch!(
("a", Utf8, ["foo", "bar", "baz"]),
("b", Utf8, ["one", "two", "three"])
)
.unwrap()
}Same for the partitioned test:
let batch = record_batch!(
("a", Utf8, ["x", "y", "x"]),
("b", Utf8, ["one", "two", "three"])
)
.unwrap();| let batch = | ||
| RecordBatch::try_new(Arc::clone(&schema), vec![col_a, col_b]).unwrap(); |
There was a problem hiding this comment.
Same comment as above about reusing the record_batch! macro
| /// Sum of compressed row group sizes in bytes. | ||
| /// | ||
| /// Note: this may differ slightly from the actual on-disk file size as it | ||
| /// excludes the Parquet footer, page indexes, and other metadata overhead. |
There was a problem hiding this comment.
FileWriteMetada should be format agnostic, right? Here we're talking about row group sizes, which isn't a concept in all of the datasources. I think we just need a comment update here, but if this is specific to parquet then it probably needs to go into the format_metadata.
There was a problem hiding this comment.
Rewrote the doc to be format-neutral:
/// Best-effort total encoded data size in bytes.
///
/// How this value is computed depends on the format. For Parquet it is the
/// sum of compressed row-group sizes (which excludes the footer and page
/// indexes); for other formats it may be the full file size or unavailable
/// (zero). Consumers should treat this as an approximation.
pub byte_size: u64,…asts, format-agnostic docs, record_batch! macro
|
Hi @timsaucer, thanks for all the feedback, I've responded to each of the points along with Copilot, PTAL. |
timsaucer
left a comment
There was a problem hiding this comment.
Generally this looks good and I really like how you've expanded test coverage. In addition to my one comment (you can ignore the nit if you disagree) it looks like there are some CI failures.
| byte_size, | ||
| // Full Parquet metadata is accessible via ParquetSink::written() | ||
| // for Rust consumers. Thrift serialization for FFI consumers can | ||
| // be added when a concrete use case (e.g. Python via PyO3) needs it. | ||
| format_metadata: None, |
There was a problem hiding this comment.
Since this is being left for future work instead of exposing the metadata here, can you open an issue to track this and link the comment to the issue?
There was a problem hiding this comment.
Done, opened #24010 to track this. Updated the code comment to link to the issue as well.
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod sink_tests { |
There was a problem hiding this comment.
| mod sink_tests { | |
| mod tests { |
Nit, but consistent with the repo and rust standard practice.
There was a problem hiding this comment.
Good catch, fixed. Renamed to tests for consistency with the rest of the repo.
Which issue does this PR close?
Closes #23472.
Rationale for this change
External table formats (Apache Iceberg, Delta Lake, Apache Hudi) require per-file column statistics (column_sizes, null_counts, lower/upper bounds, split_offsets) when committing written files to their catalogs. Currently,
ParquetSinkcomputes and stores this metadata internally (self.written) but theDataSinktrait only returns a row count forcing external consumers to either:DataSinkExec.sink()toParquetSinkand callwritten()(fragile, undocumented)This PR adds a standard, non-breaking mechanism to expose per-file metadata through the
DataSinktrait.What changes are included in this PR?
New type in
datafusion-datasource:FileWriteMetadata: path + row_count + byte_size + optional format-specific metadata bytesTrait extension (non-breaking, default implementation):
DataSink::file_metadata()Vec<FileWriteMetadata>(default: empty)ParquetSink implementation:
file_metadata()using the existingself.writtenHashMap that already collectsParquetMetaDataduring writes zero additional I/ODataSinkExec convenience:
file_metadata()method that delegates to the inner sink, avoiding the downcast danceThe typed
ParquetMetaDataremains accessible via the existingParquetSink::written()method for Rust consumers who need full column-level statistics directly. Theformat_metadata: Option<Bytes>field onFileWriteMetadatais reserved for future Thrift serialization when FFI consumers (e.g. Python via PyO3) need it.Are these changes tested?
Yes 14 new tests across both crates:
datafusion-datasource(7 tests):FileWriteMetadataequality and clone behaviorfile_metadata()returns empty afterwrite_allfile_metadata()returns correct entriesDataSinkExec::file_metadata()delegates correctlydatafusion-datasource-parquet(7 tests):write_allcount == sum of per-file row_counts)ParquetSink::written()accessorDataSinkExecwrapper withArc<dyn DataSink>dispatchAll existing
parquet_sink_write*tests continue to pass unchanged.Are there any user-facing changes?
New public API surface (additive only, no breaking changes):
DataSink::file_metadata()default methodFileWriteMetadatastructDataSinkExec::file_metadata()convenience method